Skip to content

release: v1.19.0#335

Merged
TemujinCalidius merged 11 commits into
mainfrom
dev
Jul 23, 2026
Merged

release: v1.19.0#335
TemujinCalidius merged 11 commits into
mainfrom
dev

Conversation

@TemujinCalidius

Copy link
Copy Markdown
Owner

v1.19.0 — MINOR. Six features, two security fixes, no breaking changes.

Merges dev into main. True merge, not a squash (shared history — see docs/releasing.md).

🔒 Security

✨ Added

🐛 Fixed

Verified

Full gate suite green on every constituent PR. Push confirmed end-to-end on the demo including key regeneration — the rotation path that invalidates every subscription. sharp verified with a libvips runtime round-trip plus a live /_next/image check on the demo.

TemujinCalidius and others added 11 commits July 21, 2026 11:22
Both from Samuel's hands-on testing of the sidebar shipped in v1.18.0.

- sidebar.side (left|right, default right). Done with CSS `order` rather than by
  reordering the DOM, so content stays FIRST in source order: on mobile (single
  column) the reader gets the post before the sidebar either way, and only the
  desktop columns swap.
- sidebar.blocks — one ORDERED CSV (about,recent,sections,connect) rather than
  toggles plus a separate order field. The list order IS the render order, and
  omitting a block hides it. That also settles #307 item 4: drop `sections` and
  your nav appears once instead of in both the header and the sidebar.

New src/lib/sidebar.ts holds the pure helpers (parse/resolve/labels/guards),
prisma-free so the admin client bundle can import it, mirroring @/lib/categories.
Config plumbing follows the established four points: SITE_CONFIG_FIELDS,
RuntimeSiteConfig, the overlay, and validateSiteConfigValue.

The blocks validator REJECTS unknown names rather than dropping them, so a typo
("recentt") surfaces at save time instead of silently hiding a block. Sidebar.tsx
renders from a keyed map in the configured order; each block still returns null
when it has nothing to show.

Admin controls appear only when the shell is "sidebar"; the wizard gains the side
picker plus a review-step row. The blocks input uses the same raw-text state as
categories, so typing a comma isn't eaten by a join()-bound controlled input.

Verified live (LAYOUT_SHELL=sidebar): left renders grid-cols-[300px_1fr] with
lg:order-1 and no [1fr_300px]; SIDEBAR_BLOCKS=connect,about hides Sections and
puts Connect before About in the DOM. 600 tests pass.

Co-authored-by: Samuel Lison <samuel@slcolonies.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…7, @atproto/api 0.20.31 (#320)

Triage-verified patch refresh: framework patch (next 16.2.10→16.2.11) plus React,
postcss, marked, @atproto/api and eslint-config-next. tsc clean, 600 tests, next
build, audit unchanged (3 moderate — the known postcss-in-next chain, #12).

sharp held at ^0.34.5 (duplicate-libvips risk, undetectable by CI); typescript
still blocked upstream (#234). Confirmed single sharp @0.34.5, no nested copies.

Co-authored-by: Samuel Lison <samuel@slcolonies.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…d + rotation-safe (#59) (#321)

Push previously needed `npx web-push generate-vapid-keys` + three .env vars.
Now Admin → Site settings → Phone notifications generates the keypair in one
click. Mirrors the encrypted-secret pattern (analytics-secret.ts): public key +
subject as plain SiteSetting rows under integration.push.*, the private signing
key encrypted (secret-box), env fallback, status never exposes the private key.

The hard part — key rotation:
- Every generate/save/clear PURGES prisma.pushSubscription (deleteMany) because
  each subscription is bound to the OLD applicationServerKey and can never
  receive a send signed by a new key.
- push.ts had module-level `const PUBLIC/PRIVATE` read once at import and a
  one-shot ensureConfigured() memoization — after a rotation it would keep
  signing with the dead key. Replaced with a fingerprint check: getVapidConfig()
  reads fresh, and setVapidDetails re-inits only when the active key changes.
- PushSetup.tsx re-used a stale subscription while reporting "on" (no
  applicationServerKey comparison anywhere) → a zombie "on-but-dead" state. Now
  it compares the subscription's key to the server's current public key (and the
  server's subscription count) on init, and unsubscribes + re-subscribes on
  mismatch in enable().

New: src/lib/push-config.ts, /api/admin/push-keys route (generate/save/clear,
verifyAdmin+verifyOrigin, never returns the private key), admin panel section
with a rotation warning. push/route + push/test switched to the async getters.

Tests (+17): encrypted round-trip + env fallback, status-never-leaks-private,
purge-on-save/generate/clear, no-purge-without-ADMIN_SECRET, fingerprint re-init
on rotation, route auth/CSRF/actions. 617 pass.

On-device verification (rotation → re-subscribe, no zombie) is for the demo —
covered by the PR's device checklist.

Co-authored-by: Samuel Lison <samuel@slcolonies.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
#322)

Third built-in theme and the first to use the sidebar shell: a left sidebar of
bio / recent posts / links beside a serif reading column, warm charcoal ground,
pine-green accent. Distinct from the default (cool glass) and Editorial (sepia
print). Dark, per the contrast invariant.

Engine addition: a Theme can now preset the sidebar's side + blocks, not just
the layout regions. New resolveSidebar(themeId, overrides) mirrors resolveLayout
— owner override → theme preset → built-in default (right, all blocks) — and the
site-settings sidebar resolution runs through it against the resolved theme id.
RuntimeSiteConfig.sidebar stays the resolved shape, so (public)/layout.tsx and
the admin are unchanged; existing sidebar tests pass untouched (the default
theme presets nothing → same right/all-blocks).

Classic Blog presets layout {shell:sidebar, header:minimal, feed:list} and
sidebar {side:left, blocks:[about,recent,connect]} — sections omitted so the nav
lives once (in the minimal header's menu), the combination flagged in #307. It
auto-appears in the admin picker + wizard (both iterate THEMES) with zero UI
wiring.

Verified live (THEME=classic): left grid-cols-[300px_1fr] + <aside>, Sections
absent, Connect present, and the pine/charcoal/serif tokens injected. Tests:
resolveSidebar precedence, Classic Blog registration; the contrast invariant now
covers it. 624 pass.

Co-authored-by: Samuel Lison <samuel@slcolonies.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…59) (#323)

The last two "safe + actionable" env-only settings from #59's audit. Both were
read directly from process.env; now they overlay through the SiteSetting config
like everything else, editable in Admin → Site settings → Security.

- New "int" FieldType with a 0–3650 range check in validateSiteConfigValue.
- security.adminSessionTtlDays (default 30) + security.appTokenTtlDays (0 = never
  expires) in SITE_CONFIG_FIELDS / RuntimeSiteConfig / defaults / overlay, with
  env (ADMIN_SESSION_TTL_DAYS / APP_TOKEN_TTL_DAYS) as the default via site.config.
- auth.ts adminSessionTtlMs() and oauth/token appTokenExpiry() now read
  getRuntimeSiteConfig() (60s cache) — both via a dynamic import so the widely-
  imported auth module never statically pulls in site-settings' prisma graph.
  Both call sites were already async.

Caveat surfaced in the UI: changes affect only newly-minted sessions/tokens; no
purge, and setting an app-token TTL starts expiring newly-issued tokens.

Tests: int validation (range/integer/negative), the TTL overlay, and the
oauth-token test updated to drive the TTL through mocked runtime config rather
than process.env. 626 pass.

Co-authored-by: Samuel Lison <samuel@slcolonies.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
New installs can set a profile picture + banner during setup instead of only
after, in the admin panel. /api/media needs an owner cookie that doesn't exist
pre-setup, so this adds a setup-token-gated path.

- Extracted the image optimise/EXIF-strip/write pipeline out of the media route
  into src/lib/media.ts (saveUploadedImage → returns the RELATIVE path, so a
  caller before SITE_URL is configured gets a stable value), plus a shared
  validateImagePath guard. The media route now calls the shared helper.
- Hardened saveUploadedImage: a corrupt/undecodable image returns ok:false → a
  400, not the previous unhandled 500 (benefits the media route too).
- Extracted getOrCreateSetupToken into src/lib/setup-token.ts (+ verifySetupToken)
  so both /api/setup and the new upload agree on one token; the setup route now
  uses the shared helper.
- New /api/setup/media, gated exactly like /api/setup: admin auth once
  ADMIN_SECRET is set, else the setup token (x-setup-token header).
- Wizard: avatar/banner controls on the password step (where the token lives),
  posting to /api/setup/media, then avatarPath/bannerPath ride the /api/setup
  body and are validated + written into the claimed SiteSettings row.

Verified live: wrong/no token → 401, valid token + real image → 201 + relative
path, corrupt file → 400. Tests: validateImagePath, saveUploadedImage
(unsupported + undecodable), the setup-media token gate, and the setup route's
avatar/banner write + rejection. 639 pass.

Co-authored-by: Samuel Lison <samuel@slcolonies.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…old (#250) (#325)

docs/theming.md predated the whole theming engine — it told owners to hand-edit
globals.css and swap avatar/banner files on disk (both now web-editable). Rewrote
it around: (1) what's editable from Admin → Appearance (theme, per-region layout,
accent, sidebar, avatar/banner); (2) the Theme token contract + buildThemeStyle
diff + the region×variant model; (3) a developer scaffold for adding a built-in
theme (one data file + one registry line), with the dark/contrast invariant,
self-hosted-font limit, and pinned-accent-ramp constraints spelled out. This is
the developer half of the title's "custom-theme scaffold".

Co-authored-by: Samuel Lison <samuel@slcolonies.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…py tree-wide (#329, #12) (#330)

sharp <0.35.0 carries four libvips CVEs (GHSA-f88m-g3jw-g9cj, CVSS 7.0) and our
direct dependency was the vulnerable one. Unlike the postcss chain this was
reachable from remote input: proxyImage() in fedi-media.ts decodes bytes fetched
from arbitrary remote instances, and /_next/image is a public endpoint that
decodes thumbnails from the allow-listed PeerTube hosts.

Bumping alone is not enough — Next pins its own optional sharp at ^0.34.5, so a
disjoint direct range leaves a second vulnerable copy plus a duplicate libvips
binary, which tsc/tests/build cannot detect. The overrides entry is what dedupes
the tree onto one patched copy. npm rejects an override that conflicts with a
direct dependency, so both use npm's $name reference and stay in sync on future
bumps.

The same treatment for postcss dedupes Next's nested copy and clears the last
transitive advisory (#12): npm audit now reports 0 vulnerabilities.

Verified beyond the standard gates, since a broken or duplicated native binary
is invisible to them: npm ls shows one sharp (0.35.3 overridden, deduped under
next), and a runtime round-trip exercising every sharp API the codebase uses
(metadata, resize/fit-inside, rotate, webp, png, toBuffer, limitInputPixels)
passes on libvips 8.18.3 — including confirming limitInputPixels still rejects
an over-budget image, so the decompression-bomb defence is intact.

Co-authored-by: Samuel Lison <samuel@slcolonies.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
#331)

Push has been unusable on any instance without VAPID keys since the admin
generator shipped in #321, because the enrollment UI never checked for them.

PushSetup only fetched /api/push when a subscription already existed, so with no
keypair it never learned configured:false — it showed "Enable phone
notifications" regardless, and enable() then threw "push not configured on
server", rendered as small grey text. The panel that fixes it lives in Admin ->
Site settings, and nothing pointed there. That is what a fresh install hit.

Now the mount always asks the server, and a missing keypair is its own state:
"Set up phone notifications" generates the keypair via /api/admin/push-keys and
continues straight into the subscribe flow, so it stays one click. Safe from
here — the bell renders only behind isAdmin in all three header variants, the
route re-checks admin + origin, and with no keys there are no subscriptions for
the generate to purge. Failures now surface the server's own reason (e.g.
ADMIN_SECRET unset, so the private key can't be encrypted at rest).

Notification.requestPermission() deliberately stays the first await in enable(),
since the prompt must run inside the user gesture.

The enrollment decision logic moves to src/lib/push-client.ts as pure functions
over primitives, so it can be tested — the suite runs in the node environment
with no DOM and the component is a client component. The rotation trap is locked
down there too: a subscription bound to a rotated key, or any subscription when
the server holds none, must resolve to "ready" and never "on".

Also documents Web Push in docs/configuration.md (it had none anywhere) and
corrects the stale layout.tsx comment claiming keys come from .env.local.

Co-authored-by: Samuel Lison <samuel@slcolonies.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…) (#332)

The blocker on a light theme — and on the in-admin custom palette that closes
#250 — is that body text was hard-coded to Tailwind neutrals. `@theme` EXTENDS
Tailwind's palette rather than replacing it, so `gray-*` stays fixed while
`surface-*` moves: on a paper ground `text-white` lands at ~1:1, an invisible
site. No theme token could reach those utilities.

Adds a content-* text ramp (content / -strong / -muted / -subtle / -faint /
-dim / -ghost, brightest to dimmest) as real theme tokens, and migrates the
layout chrome onto it: 64 utilities across Navbar, the three header variants,
the three footer variants, Sidebar, MobileMenu, NotificationBell and PushSetup.

Zero visual change, by construction rather than by inspection: globals.css
defaults each token to the Tailwind neutral it replaces
(--color-content-faint: var(--color-gray-500)), so text-content-faint resolves
through to the very same value — including the lab() refinement Tailwind emits
for wide-gamut displays. All three themes share the default ramp, so every
content token diffs out of buildThemeStyle and nothing extra is emitted; a test
asserts that for editorial.

Note the ramp's hexes are Tailwind v4's neutrals, not the v3 values people
remember (gray-500 is #6a7282, not #6b7280) — v4 regenerated the palette in
oklch. Using the remembered hexes would have shifted every migrated colour.

The contrast invariant is now also expressed against the tokens — the form that
survives the migration and that a light theme must satisfy — asserting the
readable tier stays AA on each theme's own ground, plus ramp monotonicity. The
hard-coded neutral check STAYS: most of the app is still un-migrated, so the
dark-only guard has to hold until it isn't.

Co-authored-by: Samuel Lison <samuel@slcolonies.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Also reorders the section headings to the documented contract (Added,
Changed, Fixed, Security) before the section freezes — released sections
are immutable afterwards, so this is the last point it can be corrected
without a changelog-resync.

Co-authored-by: Samuel Lison <samuel@slcolonies.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@TemujinCalidius
TemujinCalidius merged commit fe8ee09 into main Jul 23, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant